31 | 误删数据后除了跑路,还能怎么办?

传统的高可用架构是不能预防误删数据的,因为主库的一个 drop table 命令,会通过 binlog 传给所有从库和级联从库,进而导致整个集群的实例都会执行这个命令。
为了找到解决误删数据的更高效的方法,我们需要先对和 MySQL 相关的误删数据,做下分类:

  1. 使用 delete 语句误删数据行;
  2. 使用 drop table 或者 truncate table 语句误删数据表;
  3. 使用 drop database 语句误删数据库;
  4. 使用 rm 命令误删整个 MySQL 实例。

误删行

在[[实践篇-03 23-30节#24 MySQL是怎么保证主备一致的?]]我们提到如果是使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。
Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL
具体恢复数据时,对单个事务做如下处理:

  1. 对于 insert 语句,对应的 binlog event 类型是 Write_rows event,把它改成 Delete_rows event 即可;

  2. 同理,对于 delete 语句,也是将 Delete_rows event 改为 Write_rows event;

  3. 而如果是 Update_rows 的话,binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。

如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务:

1
2
3
(A)delete ...
(B)insert ...
(C)update ...

现在要把数据库恢复回这三个事务操作之前的状态,用 Flashback 工具解析 binlog 后,写回主库的命令是:

1
2
3
(reverse C)update ...
(reverse B)delete ...
(reverse A)insert ...

也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行
需要说明的是,我不建议你直接在主库上执行这些操作。
恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。
为什么要这么做呢?
这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏
当然,我们不止要说误删数据的事后处理办法,更重要是要做到事前预防。我有以下两个建议:

  1. sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。
  2. 代码上线前,必须经过 SQL 审计

你可能会说,设置了 sql_safe_updates=on,如果我真的要把一个小表的数据全部删掉,应该怎么办呢?
如果你确定这个删除操作没问题的话,可以在 delete 语句中加上 where 条件,比如 where id>=0。
但是,delete 全表是很慢的,需要生成回滚日志、写 redo、写 binlog。所以,从性能角度考虑,你应该优先考虑使用 truncate table 或者 drop table 命令
使用 delete 命令删除的数据,你还可以用 Flashback 来恢复。而使用 truncate /drop table 和 drop database 命令删除的数据,就没办法通过 Flashback 来恢复了。为什么呢?
这是因为,即使我们配置了 binlog_format=row,执行这三个命令时,记录的 binlog 还是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息是恢复不出数据的
那么,如果我们真的是使用这几条命令误删数据了,又该怎么办呢?

误删库 / 表

这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog
在这两个条件都具备的情况下,假如有人中午 12 点误删了一个库,恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
  2. 用备份恢复出一个临时库;
  3. 从日志备份里面,取出凌晨 0 点之后的日志;
  4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。

这个流程的示意图如下所示:
image.png
关于这个过程,我需要和你说明如下几点:

  1. 为了加速数据恢复,如果这个临时库上有多个数据库,你可以在使用 mysqlbinlog 命令时,加上一个–database 参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要应用其他库日志的情况。
  2. 在应用日志的时候,需要跳过 12 点误操作的那个语句的 binlog:
    • 如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用–stop-position 参数执行到误操作之前的日志,然后再用–start-position 从误操作之后的日志继续执行
    • 如果实例使用了 GTID 模式,就方便多了。假设误操作命令的 GTID 是 gtid1,那么只需要执行 set gtid_next=gtid1;begin;commit; 先把这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时候,就会自动跳过误操作的语句。

不过,即使这样,使用 mysqlbinlog 方法恢复数据还是不够快,主要原因有两个:

  1. 如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是 mysqlbinlog 工具并不能指定只解析一个表的日志
  2. 用 mysqlbinlog 解析出日志应用,应用日志的过程就只能是单线程。我们在[[实践篇-03 23-30节#26 备库为什么会延迟好几个小时?]]中介绍的那些并行复制的方法,在这里都用不上。

一种加速的方法是, 在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:

  1. 在 start slave 之前,先通过执行
    change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表
  2. 这样做也可以用上并行复制技术,来加速整个数据恢复过程。

这个过程的示意图如下所示。
image.png
可以看到,图中 binlog 备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上已经删除了临时实例需要的 binlog 的话,我们可以从 binlog 备份系统中找到需要的 binlog,再放回备库中。
假设,我们发现当前临时实例需要的 binlog 是从 master.000005 开始的,但是在备库上执行 show binlogs 显示的最小的 binlog 文件是 master.000007,意味着少了两个 binlog 文件。这时,我们就需要去 binlog 备份系统中找到这两个文件。
把之前删掉的 binlog 放回备库的操作步骤,是这样的:

  1. 备份系统下载 master.000005 和 master.000006 这两个文件,放到备库的日志目录下;
  2. 打开日志目录下的 master.index 文件,在文件开头加入两行,内容分别是 “./master.000005”和“./master.000006”;
  3. 重启备库,目的是要让备库重新识别这两个日志文件;
  4. 现在这个备库上就有了临时库需要的所有 binlog 了,建立主备关系,就可以正常同步了。

不论是把 mysqlbinlog 工具解析出的 binlog 文件应用到临时库,还是把临时库接到备库上,这两个方案的共同点是:误删库或者表后,恢复数据的思路主要就是通过备份,再加上应用 binlog 的方式
也就是说,这两个方案都要求备份系统定期备份全量日志,而且需要确保 binlog 在被从本地删除之前已经做了备份。
但是,一个系统不可能备份无限的日志,你还需要根据成本和磁盘空间资源,设定一个日志保留的天数。如果你的 DBA 团队告诉你,可以保证把某个实例恢复到半个月内的任意时间点,这就表示备份系统保留的日志时间就至少是半个月。
另外,我建议你不论使用上述哪种方式,都要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。为什么这么说呢?
这里的原因,主要包括两个方面:

  1. 虽然“发生这种事,大家都不想的”,但是万一出现了误删事件,能够快速恢复数据,将损失降到最小,也应该不用跑路了。
  2. 而如果临时再手忙脚乱地手动操作,最后又误操作了,对业务造成了二次伤害,那就说不过去了。

延迟复制备库

虽然我们可以通过利用并行复制来加速恢复数据的过程,但是这个方案仍然存在“恢复时间不可控”的问题。
如果一个库的备份特别大,或者误操作的时间距离上一个全量备份的时间较长,比如一周一备的实例,在备份之后的第 6 天发生误操作,那就需要恢复 6 天的日志,这个恢复时间可能是要按天来计算的。
那么,我们有什么方法可以缩短恢复数据需要的时间呢?
如果有非常核心的业务,不允许太长的恢复时间,我们可以考虑搭建延迟复制的备库。 这个功能是 MySQL 5.6 版本引入的。
一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。
延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。
比如你把 N 设置为 3600,这就代表了如果主库上有数据被误删了,并且在 1 小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。
这样的话,你就随时可以得到一个,只需要最多再追 1 小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间。

预防误删库 / 表的方法

虽然常在河边走,很难不湿鞋,但终究还是可以找到一些方法来避免的。所以这里,我也会给你一些减少误删操作风险的建议。
第一条建议是,账号分离。这样做的目的是,避免写错命令。比如:

  • 我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
  • 即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。

第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如:

  • 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
  • 改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。

rm 删除数据

其实,对于一个有高可用机制的 MySQL 集群来说,最不怕的就是 rm 删除数据了。只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA 系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。
这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。
当然了,现在不止是 DBA 有自动化系统,SA(系统管理员)也有自动化系统,所以也许一个批量下线机器的操作,会让你整个 MySQL 集群的所有节点都全军覆没。
应对这种情况,我的建议只能是说尽量把你的备份跨机房,或者最好是跨城市保存

我要强调的是,预防远比处理的意义来得大。
另外,在 MySQL 的集群方案中,会时不时地用到备份来恢复实例,因此定期检查备份的有效性也很有必要。

问题

回忆下你亲身经历过的误删数据事件吧,你用了什么方法来恢复数据呢?你在这个过程中得到的经验又是什么呢?
@苍茫 同学提到了一个例子,我觉得值得跟大家分享一下。运维的同学直接拷贝文本去执行,SQL 语句截断,导致数据库执行出错。
从浏览器拷贝文本执行,是一个非常不规范的操作。除了这个例子里面说的 SQL 语句截断问题,还可能存在乱码问题。
一般这种操作,如果脚本的开发和执行不是同一个人,需要开发同学把脚本放到 git 上,然后把 git 地址,以及文件的 md5 发给运维同学。
这样就要求运维同学在执行命令之前,确认要执行的文件的 md5,跟之前开发同学提供的 md5 相同才能继续执行。
另外,我要特别点赞一下 @苍茫 同学复现问题的思路和追查问题的态度。
@linhui0705 同学提到的“四个脚本”的方法,我非常推崇。这四个脚本分别是:备份脚本、执行脚本、验证脚本和回滚脚本。如果能够坚持做到,即使出现问题,也是可以很快恢复的,一定能降低出现故障的概率。
不过,这个方案最大的敌人是这样的思想:这是个小操作,不需要这么严格。
@Knight²º¹⁸ 给了一个保护文件的方法,我之前没有用过这种方法,不过这确实是一个不错的思路。
为了数据安全和服务稳定,多做点预防方案的设计讨论,总好过故障处理和事后复盘。方案设计讨论会和故障复盘会,这两种会议的会议室气氛完全不一样。经历过的同学一定懂的。

其他问题

[!note]
评论区一个基本最快的恢复数据的方法

我遇到过一个线上误truncate表的,最终选择的处理过程如下:
1、创建一个同版本的空mysql实例,建一个名字+结构一模一样的表
2、discard这个表的tablespace
3、从之前的备份集中 innobackupex –apply-log 并记录binlog位置(用innobackupex备份的)。还原后找到误操作表的.ibd文件,copy到新实例对应的位置
4、在之前创建的mysql实例上import tablespace
5、利用mysqlbinlog 处理增量数据
6、最后导出 再导入

[!note]
通过 chatrr +i 命令给所有重要的文件增加了 i 权限属性

很久之前,升级mongodb,在备份数据文件时,备份了指向数据文件的软连接(当时没注意是软连接),导致在删除数据文件后,再通过备份数据文件恢复数据时找不到文件,这时才发现自己备份的只是一个软连接,最后是通过备份节点才恢复的数据。当时还没自动化运维工具,线上操作也不规范。后来通过 chatrr +i 命令给所有重要的文件增加了 i 权限属性,这样哪怕 root 用户都无法直接删除文件。

[!question]
恢复出临时库后,怎么应用到主库?

如果原库是删表,就把临时库里面的表导过去,小表逻辑导,大表可以用“透明表空间机制”物理导;如果原库是误删了一些行,那只能在临时库里面select数据出来,按照业务的需要去补了

MySQL 的透明表空间机制是指,当一个表需要扩展或缩小其表空间时,MySQL 系统会自动完成这个过程。具体来说,当一个表需要扩展其表空间时,MySQL 系统会自动将数据文件分成多个块,并将这些块分别存储到不同的磁盘上,以实现数据的分布式存储。当一个表需要缩小其表空间时,MySQL 系统会自动将数据文件合并成一个块,并将这个块存储到一个磁盘上,以实现数据的集中存储。
透明表空间机制
1.源库创建相同表结构的表。
2.discard tablespace 删除表空间随之idb也会被删除。
3.cp idb等文件到源库。
4.import tablespace

32 | 为什么还有kill不掉的语句?

在 MySQL 中有两个 kill 命令:一个是 kill query + 线程 id,表示终止这个线程中正在执行的语句;一个是 kill connection + 线程 id,这里 connection 可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。
不知道你在使用 MySQL 的时候,有没有遇到过这样的现象:使用了 kill 命令,却没能断开这个连接。再执行 show processlist 命令,看到这条语句的 Command 列显示的是 Killed。
你一定会奇怪,显示为 Killed 是什么意思,不是应该直接在 show processlist 的结果里看不到这个线程了吗?

show processlist 显示用户正在执行的线程,显示的信息来自information_schema 中的 processlist 表

今天,我们就来讨论一下这个问题。
其实大多数情况下,kill query/connection 命令是有效的。比如,执行一个查询的过程中,发现执行时间太久,要放弃继续查询,这时我们就可以用 kill query 命令,终止这条查询语句
还有一种情况是,语句处于锁等待的时候,直接使用 kill 命令也是有效的。我们一起来看下这个例子:
image.png
可以看到,session C 执行 kill query 以后,session B 几乎同时就提示了语句被中断。这,就是我们预期的结果。

收到 kill 以后,线程做什么?

但是,这里你要停下来想一下:session B 是直接终止掉线程,什么都不管就直接退出吗?显然,这是不行的。
我在[[基础篇 1-8节#06 全局锁和表锁 :给表加个字段怎么有这么多阻碍?]]中讲过,当对一个表做增删改查操作时,会在表上加 MDL 读锁。所以,session B 虽然处于 blocked 状态,但还是拿着一个 MDL 读锁的。如果线程被 kill 的时候,就直接终止,那之后这个 MDL 读锁就没机会被释放了。
这样看来,kill 并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”

其实,这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。

实现上,当用户执行 kill query thread_id_B 时,MySQL 里处理 kill 命令的线程做了两件事:【注意:这个线程是另外一个线程】

  1. 把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
  2. 给 session B 的执行线程发一个信号。

为什么要发信号呢?
因为像图 1 的我们例子里面,session B 处于锁等待状态,如果只是把 session B 的线程状态设置 THD::KILL_QUERY,线程 B 并不知道这个状态变化,还是会继续等待。发一个信号的目的,就是让 session B 退出等待,来处理这个 THD::KILL_QUERY 状态。
上面的分析中,隐含了这么三层意思:

  1. 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是 THD::KILL_QUERY,才开始进入语句终止逻辑;
  2. 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;
  3. 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的

到这里你就知道了,原来不是“说停就停的”。
接下来,我们再看一个 kill 不掉的例子,也就是我们在前面[[#29 如何判断一个数据库是不是出问题了?]]中提到的 innodb_thread_concurrency 不够用的例子。
首先,执行 set global innodb_thread_concurrency=2,将 InnoDB 的并发线程上限数设置为 2;然后,执行下面的序列:
image.png
可以看到:

  1. sesssion C 执行的时候被堵住了;
  2. 但是 session D 执行的 kill query C 命令却没什么效果

    因为还没获得查询的权力,所以kill query无效。但是这时候获得了连接的权力,所以kill connection有效

  3. 直到 session E 执行了 kill connection 命令,才断开了 session C 的连接,提示“Lost connection to MySQL server during query”,
  4. 但是这时候,如果在 session E 中执行 show processlist,你就能看到下面这个图。

image.png
这时候,id=12 这个线程的 Commnad 列显示的是 Killed。也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。

说明:仅仅断开的是mysql客户端与服务端之间的链接,语句还在执行中

为什么在执行 kill query 命令时,这条语句不像第一个例子的 update 语句一样退出呢?
在实现上,等行锁时,使用的是 pthread_cond_timedwait 函数,这个等待状态可以被唤醒。但是,在这个例子里,12 号线程的等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,就调用 nanosleep 函数进入 sleep 状态。
也就是说,虽然 12 号线程的状态已经被设置成了 KILL_QUERY,但是在这个等待进入 InnoDB 的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。
而当 session E 执行 kill connection 命令时,是这么做的,

  1. 把 12 号线程状态设置为 KILL_CONNECTION;
  2. 关掉 12 号线程的网络连接。因为有这个操作,所以你会看到,这时候 session C 收到了断开连接的提示。

那为什么执行 show processlist 的时候,会看到 Command 列显示为 killed 呢?其实,这就是因为在执行 show processlist 的时候,有一个特别的逻辑:

1
如果一个线程的状态是 KILL_CONNECTION,就把 Command 列显示成 Killed。

所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。那这个线程什么时候会退出呢?
答案是,只有等到满足进入 InnoDB 的条件后,session C 的查询语句继续执行,然后才有可能判断到线程状态已经变成了 KILL_QUERY 或者 KILL_CONNECTION,再进入终止逻辑阶段。
到这里,我们来小结一下。
这个例子是 kill 无效的第一类情况,即:线程没有执行到判断线程状态的逻辑。 跟这种情况相同的,还有由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程的状态。
另一类情况是,终止逻辑耗时较长。 这时候,从 show processlist 结果上看也是 Command=Killed,需要等到终止逻辑完成,语句才算真正完成。这类情况,比较常见的场景有以下几种:

  1. 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长
  2. 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
  3. DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。

之前有人问过我,如果直接在客户端通过 Ctrl+C 命令,是不是就可以直接终止线程呢?
答案是,不可以
这里有一个误解,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的
而由于 MySQL 是停等协议,所以这个线程执行的语句还没有返回的时候,再往这个连接里面继续发命令也是没有用的。实际上,执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 kill query 命令
所以,你可别以为在客户端执行完 Ctrl+C 就万事大吉了。因为,要 kill 掉一个线程,还涉及到后端的很多操作。

这些“kill 不掉”的情况,其实是因为发送 kill 命令的客户端,并没有强行停止目标线程的执行,而只是设置了个状态,并唤醒对应的线程。而被 kill 的线程,需要执行到判断状态的“埋点”,才会开始进入终止逻辑阶段。并且,终止逻辑本身也是需要耗费时间的。
所以,如果你发现一个线程处于 Killed 状态,你可以做的事情就是,通过影响系统环境,让这个 Killed 状态尽快结束。
比如,如果是第一个例子里 InnoDB 并发度的问题,你就可以临时调大 innodb_thread_concurrency 的值,或者停掉别的线程,让出位子给这个线程执行。
而如果是回滚逻辑由于受到 IO 资源限制执行得比较慢,就通过减少系统压力让它加速。
做完这些操作后,其实你已经没有办法再对它做什么了,只能等待流程自己完成。

换句理解:
kill connection本质上只是把客户端的sql连接断开,后面的执行流程还是要走kill query的
额外的一个不同就是show processlist的时候,kill connection会显示“killed”

另外两个关于客户端的误解

在实际使用中,我也经常会碰到一些同学对客户端的使用有误解。接下来,我们就来看看两个最常见的误解。
第一个误解是:如果库里面的表特别多,连接就会很慢。
有些线上的库,会包含很多表(我见过最多的一个库里有 6 万个表)。这时候,你就会发现,每次用客户端连接都会卡在下面这个界面上。
image.png
而如果 db1 这个库里表很少的话,连接起来就会很快,可以很快进入输入命令的状态。因此,有同学会认为是表的数目影响了连接性能。
从第一篇文章你就知道,每个客户端在和服务端建立连接的时候,需要做的事情就是 TCP 握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关
但实际上,正如图中的文字提示所说的,当使用默认参数连接的时候,MySQL 客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:

  1. 执行 show databases;
  2. 切到 db1 库,执行 show tables;
  3. 把这两个命令的结果用于构建一个本地的哈希表。

在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。
也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。
图中的提示也说了,如果在连接命令中加上 -A,就可以关掉这个自动补全的功能,然后客户端就可以快速返回了。
这里自动补全的效果就是,你在输入库名或者表名的时候,输入前缀,可以使用 Tab 键自动补全表名或者显示提示。
实际使用中,如果你自动补全功能用得并不多,我建议你每次使用的时候都默认加 -A
其实提示里面没有说,除了加 -A 以外,加–quick(或者简写为 -q) 参数,也可以跳过这个阶段。但是,这个 –quick 是一个更容易引起误会的参数,也是关于客户端常见的一个误解。
你看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能。为什么这么说呢?
MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:

  1. 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用 API 开发,对应的就是 mysql_store_result 方法。

  2. 另一种是不缓存,读一个处理一个。如果你用 API 开发,对应的就是 mysql_use_result 方法。

MySQL 客户端默认采用第一种方式,而如果加上–quick 参数,就会使用第二种不缓存的方式。
采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。关于服务端的具体行为,我会在下一篇文章再和你展开说明。
那你会说,既然这样,为什么要给这个参数取名叫作 quick 呢?这是因为使用这个参数可以达到以下三点效果:

  • 第一点,就是前面提到的,跳过表名自动补全功能。
  • 第二点,mysql_store_result 需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;
  • 第三点,是不会把执行命令记录到本地的命令历史文件。

所以你看到了,–quick 参数的意思,是让客户端变得更快

小结

在今天这篇文章中,我首先和你介绍了 MySQL 中,有些语句和连接“kill 不掉”的情况。
这些“kill 不掉”的情况,其实是因为发送 kill 命令的客户端,并没有强行停止目标线程的执行,而只是设置了个状态,并唤醒对应的线程。而被 kill 的线程,需要执行到判断状态的“埋点”,才会开始进入终止逻辑阶段。并且,终止逻辑本身也是需要耗费时间的。
所以,如果你发现一个线程处于 Killed 状态,你可以做的事情就是,通过影响系统环境,让这个 Killed 状态尽快结束。
比如,如果是第一个例子里 InnoDB 并发度的问题,你就可以临时调大 innodb_thread_concurrency 的值,或者停掉别的线程,让出位子给这个线程执行。
而如果是回滚逻辑由于受到 IO 资源限制执行得比较慢,就通过减少系统压力让它加速。
做完这些操作后,其实你已经没有办法再对它做什么了,只能等待流程自己完成。

[!summary]
kill connection本质上只是把客户端的sql连接断开,后面的执行流程还是要走kill query的
这个理解非常到位👍 额外的一个不同就是show processlist的时候,kill connection会显示“killed”
这两句加起来可以用来替换我们文中的描述👍

问题

如果一个事务被 kill 之后,持续处于回滚状态,从恢复速度的角度看,你是应该重启等它执行结束,还是应该强行重启整个 MySQL 进程?为什么呢?
因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束
当然,如果这个语句可能会占用别的锁,或者由于占用 IO 资源过多,从而影响到了别的语句执行的话,就需要先做主备切换,切到新主库提供服务。
切换之后别的线程都断开了连接,自动停止执行。接下来还是等它自己执行完成。这个操作属于我们在文章中说到的,减少系统压力,加速终止逻辑

其他问题

[!question]
课后题。我认为需要看当时的业务场景。重启会导致其他的连接也断开,返回给其他业务连接丢失的错误。如果有很多事务在等待该事务的锁,则应该重启,让其他事务快速重试获取锁。另外如果是RR的事务隔离级别,长事务会因为数据可见性的问题,对于多版本的数据需要找到正确的版本,对读性能是不是也会有影响,这时候重启也更好。个人理解,请老师指正。

作者回复: 有考虑到对其他线程的影响,这个👍 其实这种时候往往是要先考虑切换(当然重启也是切换的) 如果只看恢复时间的话,等待会更快

[!question]
老师您好,一个表中 1个主键,2个唯一索引,1个普通索引 4个普通字段,当插入一条全部字段不为空的数据时,此时假设有4个索引文件,分别对应 主键 唯一性索引,普通索引,假设内存中没有这个数据页,那么server是直接调用innodb的接口,然后依次校验 (读取磁盘数据,验证唯一性)主键,唯一性索引,然后确认无误A时刻之后,吧主键和唯一性索引的写入内存,再把普通索引写入change buffer?那普通数据呢,是不是跟着主键一块写入内存了?

作者回复: 1. 是的,如果普通索引上的数据页这时候没有在内存中,就会使用change buffer 2. “那普通数据呢,是不是跟着主键一块写入内存了?” 你说的是无索引的字段是吧,这些数据就在主键索引上,其实改的就是主键索引。

[!question]
我想请问下老师,一个事务执行很长时间,我去kill。那么,执行这个事务过程中的数据会不会回滚?

作者回复: 这个事务执行过程中新生成的数据吗? 会回滚的(此外没有其他影响)

[!question]
如果此时仅有一个事务在执行,可以重启,此时 redo log 没有被fsync 到磁盘,重新启动恢复的时候也不会提交该事务
如果有其他事务在执行,就应该等待执行完成
老师这样理解对吗,谢谢老师

作者回复: 这个事务现在是处于回滚状态, 即使有其他事务在执行,重启后也是要回滚的,跟有没有其他事务在并行执行是没关系的哦

33 | 我查这么多数据,会不会把数据库内存打爆?

我经常会被问到这样一个问题:我的主机内存只有 100G,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光了?
这个问题确实值得担心,被系统 OOM(out of memory)可不是闹着玩的。但是,反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了?
所以说,对大表做全表扫描,看来应该是没问题的。但是,这个流程到底是怎么样的呢?

全表扫描对 server 层的影响

假设,我们现在要对一个 200G 的 InnoDB 表 db1. t,执行一个全表扫描。当然,你要把扫描结果保存在客户端,会使用类似这样的命令:

1
mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_file

你已经知道了,InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。【不创建主键系统会默认生成row_id来作为主键】
那么,这个“结果集”存在哪里呢?
实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:

  1. 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。
  2. 重复获取行,直到 net_buffer 写满,调用网络接口发出去。
  3. 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
  4. 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。【客户端读的速度慢,会导致服务端写满,拥塞】

这个过程对应的流程图如下所示。
image.png
从这个流程中,你可以看到:

  1. 一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,并不会达到 200G;
  2. socket send buffer 也不可能达到 200G(默认定义 /proc/sys/net/core/wmem_default),如果 socket send buffer 被写满,就会暂停读数据的流程。【socket send buffer 是针对每个socket的】

也就是说,MySQL 是“边读边发的”,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长
比如下面这个状态,就是我故意让客户端不去读 socket receive buffer 中的内容,然后在服务端 show processlist 看到的结果。
image.png
如果你看到 State 的值一直处于 “Sending to client”,就表示服务器端的网络栈写满了
我在上一篇文章中曾提到,如果客户端使用–quick 参数,会使用 mysql_use_result 方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现如图 2 所示的这种情况。
因此,对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存。
当然前提是查询返回结果不多。在[[#30 答疑文章(二):用动态的观点看加锁]]评论区,有同学说到自己因为执行了一个大查询导致客户端占用内存近 20G,这种情况下就需要改用 mysql_use_result 接口了。
另一方面,如果你在自己负责维护的 MySQL 里看到很多个线程都处于“Sending to client”这个状态,就意味着你要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。
而如果要快速减少处于这个状态的线程的话,将 net_buffer_length 参数设置为一个更大的值是一个可选方案。
与“Sending to client”长相很类似的一个状态是 “Sending data”,这是一个经常被误会的问题。有同学问我说,在自己维护的实例上看到很多查询语句的状态是“Sending data”,但查看网络也没什么问题啊,为什么 Sending data 要这么久?
实际上,一个查询语句的状态变化是这样的(注意:这里,我略去了其他无关的状态):

  • MySQL 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
  • 然后,发送执行结果的列相关的信息(meta data) 给客户端;
  • 再继续执行语句的流程;
  • 执行完成后,把状态设置成空字符串。

也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。比如,你可以构造一个锁等待的场景,就能看到 Sending data 状态。
image.png
image.png
可以看到,session B 明显是在等锁,状态显示为 Sending data。
也就是说,仅当一个线程处于 “等待客户端接收结果” 的状态,才会显示”Sending to client“;而如果显示成“Sending data”,它的意思只是 “正在执行”
现在你知道了,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。
在 server 层的处理逻辑我们都清楚了,在 InnoDB 引擎里面又是怎么处理的呢? 扫描全表会不会对引擎系统造成影响呢?

全表扫描对 InnoDB 的影响

在第 2和第 15 篇文章中,我介绍 WAL 机制的时候,和你分析了 InnoDB 内存的一个作用,是保存更新的结果,再配合 redo log,就避免了随机写盘
内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询
在第 2 篇文章的评论区有同学问道,由于有 WAL 机制,当事务提交的时候,磁盘上的数据页是旧的,那如果这时候马上有一个查询要来读这个数据页,是不是要马上把 redo log 应用到数据页呢?
答案是不需要。因为这时候内存数据页的结果是最新的,直接读内存页就可以了。你看,这时候查询根本不需要读磁盘,直接从内存拿结果,速度是很快的。所以说,Buffer Pool 还有加速查询的作用。
而 Buffer Pool 对查询的加速效果,依赖于一个重要的指标,即:内存命中率
你可以在 show engine innodb status 结果中,查看一个系统当前的 BP 命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上
执行 show engine innodb status ,可以看到“Buffer pool hit rate”字样,显示的就是当前的命中率。比如图 5 这个命中率,就是 99.0%。【如果不符合这个要求,可能需要适当加大innodb buffer pool的值。 http://sstask.github.io/2015/07/16/innodbbuffer/】
image.png
如果所有查询需要的数据页都能够直接从内存得到,那是最好的,对应的命中率就是 100%。但,这在实际生产上是很难做到的。
InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成**可用物理内存的 60%~80%**。
在大约十年前,单机的数据量是上百个 G,而物理内存是几个 G;现在虽然很多服务器都能有 128G 甚至更高的内存,但是单机的数据量却达到了 T 级别。
所以,innodb_buffer_pool_size 小于磁盘的数据量是很常见的。如果一个 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。
InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。
下图是一个 LRU 算法的基本模型。
image.png
InnoDB 管理 Buffer Pool 的 LRU 算法,是用链表来实现的。

  1. 在图 6 的状态 1 里,链表头部是 P1,表示 P1 是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
  2. 这时候有一个读请求访问 P3,因此变成状态 2,P3 被移到最前面;
  3. 状态 3 表示,这次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 Pm 这个数据页的内存,存入 Px 的内容,然后放到链表头部。
  4. 从效果上看,就是最久没有被访问的数据页 Pm,被淘汰了。

这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,会不会有问题呢?
假设按照这个算法,我们要扫描一个 200G 的表,而这个表是一个历史数据表,平时没有业务访问它。
那么,按照这个算法扫描的话,就会把当前的 Buffer Pool 里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说 Buffer Pool 里面主要放的是这个历史数据表的数据。【单纯的LRU算法的话,全量查询容易清除掉所有的热点数据缓存,导致缓存命中率陡然下降,可能会导致服务瞬间响应时间上升。】
对于一个正在做业务服务的库,这可不妙。你会看到,Buffer Pool 的内存命中率急剧下降,磁盘压力增加,SQL 语句响应变慢。
所以,InnoDB 不能直接使用这个 LRU 算法。实际上,InnoDB 对 LRU 算法做了改进。
image.png
在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。图中 LRU_old 指向的就是 old 区域的第一个位置,是整个链表的 5/8 处。也就是说,靠近链表头部的 5/8 是 young 区域,靠近链表尾部的 3/8 是 old 区域
改进后的 LRU 算法执行流程变成了下面这样。

  1. 图 7 中状态 1,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2。
  2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页 Pm,但是新插入的数据页 Px,是放在 LRU_old 处
  3. 处于 old 区域的数据页,每次被访问的时候都要做下面这个判断:
    • 若这个数据页在 LRU 链表中存在的时间超过了 1 秒,就把它移动到链表头部
    • 如果这个数据页在 LRU 链表中存在的时间短于 1 秒,位置保持不变。1 秒这个时间,是由参数 innodb_old_blocks_time 控制的。其默认值是 1000,单位毫秒。

备注:如果遇到连续两次的全表扫描,会把young区的3/5给覆盖掉了因为两次扫描时间间隔会超过一秒

这个策略,就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描 200G 的历史数据表为例,我们看看改进后的 LRU 算法的操作逻辑:

  1. 扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
  2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域
  3. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。

可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了 Buffer Pool,但是对 young 区域完全没有影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。

注意作者在这里说的:是下次再被访问的时候才会判断是否超过1s,下次不被访问,这个数据页就算是在LRU链表OLD区待1个小时,没有接下来对其的访问,这个数据页该被顶出去还是要被顶出去
所以全表扫描中的初始的页扫描不会对LRU链表中OLD区数据页前置有任何影响。有影响的是那些,时不时的来扫一下、时间跨度也相对比较长,这种不用系统判定,我们也肯定会判定其为热数据啦

[!note]
另外记一个自己的体会,MySQL的young和old与JVM的堆相反。也许MySQL认为老的才容易去世吧

问题

如果客户端由于压力过大,迟迟不能接收数据,会对服务端造成什么严重的影响。
这个问题的核心是,造成了“长事务”
至于长事务的影响,就要结合我们前面文章中提到的锁、MVCC 的知识点了。

  • 如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住
  • 当然读的事务也有问题,就是会导致 undo log 不能被回收,导致回滚段空间膨胀

    回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。

评论区留言点赞板:

@老杨同志 提到了更新之间会互相等锁的问题。同一个事务,更新之后要尽快提交,不要做没必要的查询,尤其是不要执行需要返回大量数据的查询;
@长杰 同学提到了 undo 表空间变大,db 服务堵塞,服务端磁盘空间不足的例子。

其他问题

[!question]
1、当net_buffer写满,会调用网络接口发出去,net_buffer里的内容是如何发给socket send buffer的,是一行一行的扔给socket send buffer,还是把net_buffer 里的内容一下子全部扔给 socket send buffer ?
2、文中说发送成功然后清空net_buffer, 这里net_buffer是如何清空的,是等net_buffer里的内容全部发送成功,然后一次性清理,还是发送成功一部分清理一部分?
3、对于一个查询,执行器拿到的所有结果,如果可以一次性放入net_buffer, 对于执行器来说是不是意味着“全都写出去了”,也就不会有 sending to client 状态?
4、只有当查询的结果,不能够全部放入net_buffer,需要等net_buffer里的内容清空后再继续放入后续的结果,这时候状态才是显示 sending to client ? 当查询结果可以全部放入net_buffer, 执行器也不管 net_buffer是否发送给 socket send buffer,都认为执行完了 ?
5、对buffer pool,当通过LRU 淘汰数据页的时候,如果此时该页的内容是新的(也就是磁盘上的内容是老的),是不是需要强制先走一个刷脏页的流程,等脏页刷完了,然后才能淘汰该数据页?

作者回复:

  1. net_buffer写满,一起发,然后清空net_buffer,组装下一批 。好问题
  2. 跟上一个问题同一个答案;
  3. 是的
  4. 是的
  5. 对,这个就是我们其他文章中介绍的,“带着邻居节点一起刷”的那个阶段。

[!question]
请教老师一个问题, 之前在开发工程中实际有碰到这样的业务,批量从MySQL中查询大量数据,每次通过限制起始+limit数量的来分批次查询,后来有同事推荐使用MySQL JDBC中的fetchSize()方法,不做分页通过一次大查询然后客户端流式读取来批量查询数据,这个内部原理是否就是文中所说的使用了mysql_use_result接口读一行处理一行实现的流式?或者也是mysql_store_result方式客户端边缓存边处理?请老师指教

作者回复: 对,这种一般就是用mysql_use_result 各有优劣吧
一次性取的好处是,对服务端只全表,只扫描一遍;坏处是可能会出现大事务。
一般更常见的做法是,分批取,然后每一批拿到最大的一个id(主键值)
下一批查询的时候用 where Id > N 这种写法

[!question]
如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。这个是指客户端的tcp滑动窗口处理没有及时确认,导致server端的网络协议栈没有多余的空间可以发送数据,导致server的处理线程停止从db读取数据发送给client,是这样理解吗

作者回复: 对的

[!question]
net_buffer 应该是针对每个请求线程单独分配的,还是共享net_buffer . 我的理解应该是每个线程一块。mysql 可以根据最大请求连接数,能够算出来mysql 使用net_buffer 的总大小。同时如果mysql 占用的内存不大,也将影响到Mysql 能够处理连接连接数的大小。 不知道这种猜测是否准确。 后面那个改进型的LRU 算法真的非常好,就跟JVM 中年轻带 老年代的内存区域划分和淘汰机制一样。在做系统设计的时候可以把这种设计应用一下。

作者回复: 你的理解是对的,每个线程(session)一个

34 | 到底可不可以使用join?

在实际生产中,关于 join 语句使用的问题,一般会集中在以下两类:

  1. 我们 DBA 不让使用 join,使用 join 有什么问题呢?
  2. 如果有两个大小不同的表做 join,应该用哪个表做驱动表呢?

今天这篇文章,我就先跟你说说 join 语句到底是怎么执行的,然后再来回答这两个问题。
为了便于量化分析,我还是创建两个表 t1 和 t2 来和你说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB;

drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();

create table t1 like t2;
insert into t1 (select * from t2 where id<=100)

可以看到,这两个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 往表 t2 里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。

Index Nested-Loop Join

我们来看一下这个语句:

1
select * from t1 straight_join t2 on (t1.a=t2.a);

如果直接使用 join 语句,MySQL 优化器可能会选择表 t1 或 t2 作为驱动表,这样会影响我们分析 SQL 语句的执行过程。所以,为了便于分析执行过程中的性能问题,我改用 straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。
现在,我们来看一下这条语句的 explain 结果。
image.png
可以看到,在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了这个索引,因此这个语句的执行流程是这样的:

  1. 从表 t1 中读入一行数据 R;
  2. 从数据行 R 中,取出 a 字段到表 t2 里去查找;
  3. 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
  4. 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。

这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为 “Index Nested-Loop Join”,简称 NLJ
它对应的流程图如下所示:
image.png
在这个流程里:

  1. 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
  2. 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
  3. 所以,整个执行流程,总扫描行数是 200。

现在我们知道了这个过程,再试着回答一下文章开头的两个问题。
先看第一个问题:能不能使用 join?
假设不使用 join,那我们就只能用单表查询。我们看看上面这条语句的需求,用单表查询怎么实现。

  1. 执行select * from t1,查出表 t1 的所有数据,这里有 100 行;
  2. 循环遍历这 100 行数据:
    • 从每一行 R 取出字段 a 的值 $R.a;
    • 执行select * from t2 where a=$R.a
    • 把返回的结果和 R 构成结果集的一行。

可以看到,在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了 100 次交互。除此之外,客户端还要自己拼接 SQL 语句和结果。
显然,这么做还不如直接 join 好

我们再来看看第二个问题:怎么选择驱动表?
在这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索
假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M
假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。
因此整个执行过程,近似复杂度是 N + N*2*log2M
显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表

如果你没觉得这个影响有那么“显然”, 可以这么理解:N 扩大 1000 倍的话,扫描行数就会扩大 1000 倍;而 M 扩大 1000 倍,扫描行数扩大不到 10 倍。

到这里小结一下,通过上面的分析我们得到了两个结论:

  1. 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好
  2. 如果使用 join 语句的话,需要让小表做驱动表

但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”
接下来,我们再看看被驱动表用不上索引的情况。

Simple Nested-Loop Join

现在,我们把 SQL 语句改成这样:

1
select * from t1 straight_join t2 on (t1.a=t2.b);

由于表 t2 的字段 b 上没有索引,因此再用图 2 的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。
你可以先设想一下这个问题,继续使用图 2 的算法,是不是可以得到正确的结果呢?如果只看结果的话,这个算法是正确的,而且这个算法也有一个名字,叫做“Simple Nested-Loop Join”。
但是,这样算来,这个 SQL 请求就要扫描表 t2 多达 100 次,总共扫描 100*1000=10 万行。
这还只是两个小表,如果 t1 和 t2 都是 10 万行的表(当然了,这也还是属于小表的范围),就要扫描 100 亿行,这个算法看上去太“笨重”了。
当然,MySQL 也没有使用这个 Simple Nested-Loop Join 算法,而是使用了另一个叫作“Block Nested-Loop Join”的算法,简称 BNL。

Block Nested-Loop Join

这时候,被驱动表上没有可用的索引,算法的流程是这样的:

  1. 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
  2. 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回

这个过程的流程图如下:
image.png
对应地,这条 SQL 语句的 explain 结果如下所示:
image.png
可以看到,在这个过程中,对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。
前面我们说过,如果使用 Simple Nested-Loop Join 算法进行查询,扫描行数也是 10 万行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join 算法的这 10 万次判断是内存操作,速度上会快很多,性能也更好。
接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。
假设小表的行数是 N,大表的行数是 M,那么在这个算法里:

  1. 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
  2. 内存中的判断次数是 M*N。

可以看到,调换这两个算式中的 M 和 N 没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。
然后,你可能马上就会问了,这个例子里表 t1 才 100 行,要是表 t1 是一个大表,join_buffer 放不下怎么办呢?
join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t1 的所有数据话,策略很简单,就是分段放。 我把 join_buffer_size 改成 1200,再执行:

1
select * from t1 straight_join t2 on (t1.a=t2.b);

执行过程就变成了:

  1. 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
  2. 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;【注意这种情况被驱动表是全表扫描】
  3. 清空 join_buffer
  4. 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。

执行流程图也就变成这样:
image.png
图中的步骤 4 和 5,表示清空 join_buffer 再复用。
这个流程才体现出了这个算法名字中 “Block”的由来,表示“分块去 join”
可以看到,这时候由于表 t1 被分成了两次放入 join_buffer 中,导致表 t2 会被扫描两次。虽然分成两次放入 join_buffer,但是判断等值条件的次数还是不变的,依然是 (88+12)*1000=10 万次。
我们再来看下,在这种情况下驱动表的选择问题。
假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。【备注:这里分k段意味着表t2需要被扫描K次,即下面说的λ*N次】
注意,这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围是 (0,1)。
所以,在这个算法的执行过程中:

  1. 扫描行数是 N+λ*N*M;
  2. 内存判断 N*M 次。

显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 M 和 N 大小确定的情况下,N 小一些,整个算式的结果会更小
所以结论是,应该让小表当驱动表
当然,你会发现,在 N+λ*N*M 这个式子里,λ才是影响扫描行数的关键因素,这个值越小越好。
刚刚我们说了 N 越大,分段数 K 越大。那么,N 固定的时候,什么参数会影响 K 的大小呢?(也就是λ的大小)答案是 join_buffer_sizejoin_buffer_size 越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少
这就是为什么,你可能会看到一些建议告诉你,如果你的 join 语句很慢,就把 join_buffer_size 改大

理解了 MySQL 执行 join 的两种算法,现在我们再来试着回答文章开头的两个问题
第一个问题:能不能使用 join 语句?

  1. 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的
  2. 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用

所以你在判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样
第二个问题是:如果要使用 join,应该选择大表做驱动表还是选择小表做驱动表?

  1. 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
  2. 如果是 Block Nested-Loop Join 算法:
    • 在 join_buffer_size 足够大的时候,是一样的;
    • 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。

所以,这个问题的结论就是,总是应该使用小表做驱动表
当然了,这里我需要说明下,什么叫作“小表”
我们前面的例子是没有加条件的。如果我在语句的 where 条件加上 t2.id<=50 这个限定条件,再来看下这两条语句:

1
2
select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;

注意,为了让两条语句的被驱动表都用不上索引,所以 join 字段都使用了没有索引的字段 b。
但如果是用第二个语句的话,join_buffer 只需要放入 t2 的前 50 行,显然是更好的。所以这里,“t2 的前 50 行”是那个相对小的表,也就是“小表”
我们再来看另外一组例子:

1
2
select t1.b,t2.* from  t1  straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=100;

这个例子里,表 t1 和 t2 都是只有 100 行参加 join。但是,这两条语句每次查询放入 join_buffer 中的数据是不一样的:

  • 表 t1 只查字段 b,因此如果把 t1 放到 join_buffer 中,则 join_buffer 中只需要放入 b 的值;
  • 表 t2 需要查所有的字段,因此如果把表 t2 放到 join_buffer 中的话,就需要放入三个字段 id、a 和 b。

这里,我们应该选择表 t1 作为驱动表。也就是说在这个例子里,“只需要一列参与 join 的表 t1”是那个相对小的表
所以,更准确地说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。

left join 和 right join 会固定驱动表吗? 不会强制,但是由于语义的关系,大概率上是按照语句上写的关系去驱动,效率是比较高的
join也是普通查询,都不需要加锁。“分两步查询,先查驱动表,然后查多个in”,如果可以用上被驱动表的索引,我觉得可以用上Index Nested-Loop Join算法,其实效果是跟拆开写类似的
select * 和 select 所有字段没有性能差异,但是如果业务真的需要,还是建议写“select 所有字段”

小結

今天,我和你介绍了 MySQL 执行 join 语句的两种可能算法,这两种算法是由能否使用被驱动表的索引决定的。而能否用上被驱动表的索引,对 join 语句的性能影响很大。
通过对 Index Nested-Loop Join 和 Block Nested-Loop Join 两个算法执行过程的分析,我们也得到了文章开头两个问题的答案:

  1. 如果可以使用被驱动表的索引,join 语句还是有其优势的;
  2. 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
  3. 在使用 join 的时候,应该让小表做驱动表。

问题

我们在上文说到,使用 Block Nested-Loop Join 算法,可能会因为 join_buffer 不够大,需要对被驱动表做多次全表扫描。
如果被驱动表是一个大表,并且是一个冷数据表,除了查询过程中可能会导致 IO 压力大以外,你觉得对这个 MySQL 服务还有什么更严重的影响吗?(这个问题需要结合上一篇文章的知识点)

自己想的
1、内存占用过高
2、锁等待变长:如果被驱动表是一个大表,并且在查询过程中需要使用锁来保证数据的一致性,那么可能会导致锁等待过长的问题
3、索引失效:当查询条件中包含大量的非唯一或者重复的数据时,可能会导致索引失效,从而导致查询效率降低
4、数据库备份和恢复时间过长

其他问题

[!question]
回答本期问题

  1. 长期占用DML锁,引发DDL拿不到锁堵慢连接池;
  2. SQL执行socket_timeout超时后业务接口重复发起,导致实例IO负载上升出现雪崩;
  3. 实例异常后,DBA kill SQL因繁杂的回滚执行时间过长,不能快速恢复可用;
  4. 如果业务采用select *作为结果集返回,极大可能出现网络拥堵,整体拖慢服务端的处理;
  5. 冷数据污染buffer pool,block nested-loop多次扫描,其中间隔很有可能超过1s,从而污染到lru 头部,影响整体的查询体验。

    第五条:如果驱动表分段,那么被驱动表就被多次读,而被驱动表又是大表,循环读取的间隔肯定得超1秒,这就会导致上篇文章提到的:“数据页在LRU_old的存在时间超过1秒,就会移到young区”。最终结果就是把大部分热点数据都淘汰了,导致“Buffer pool hit rate”命中率极低,其他请求需要读磁盘,因此系统响应变慢,大部分请求阻塞。

[!question]
explain select * from t1 straight_join t2 on (t1.a=t2.a) where t1.a < 50;
老师, 这条sql为什么t1.a的索引没有用上, t1还是走全表

作者回复: 如果数据量不够多,并且满足a<50的行,占比比较高的话,优化器有可能会认为“还要回表,还不如直接扫主键id”
备注:查询的结果集大于等于总数据行的25%,就会直接回表查询。

[!question]
文章内容「可以看到,在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了 100 次交互。除此之外,客户端还要自己拼接 SQL 语句和结果。」 这个有没有啥方法来仅通过1次交互就将这101条语句发到服务端执行?

用 in,但是不建议语句太长

[!success]
老师,MySQL自己会决定选哪个表当驱动表吗?也就是说自己不用担心语句怎么写的,join的时候不管小表在前还是大表在前,mysql优化器会自动选择小表作为驱动表,是这样理解吗

作者回复: 对,优化器不就是做这个的
当然优化器有时候会犯错误,所以还是explain看看保险哈

35 | join语句怎么优化?

在上一篇文章中,我和你介绍了 join 语句的两种算法,分别是 Index Nested-Loop Join(NLJ) 和 Block Nested-Loop Join(BNL)。
我们发现在使用 NLJ 算法的时候,其实效果还是不错的,比通过应用层拆分成多个语句然后再拼接查询结果更方便,而且性能也不会差
但是,BNL 算法在大表 join 的时候性能就差多了,比较次数等于两个表参与 join 的行数的乘积,很消耗 CPU 资源。
当然了,这两个算法都还有继续优化的空间,我们今天就来聊聊这个话题。
为了便于分析,我还是创建两个表 t1、t2 来和你展开今天的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
create table t1(id int primary key, a int, b int, index(a));
create table t2 like t1;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=1000)do
insert into t1 values(i, 1001-i, i);
set i=i+1;
end while;

set i=1;
while(i<=1000000)do
insert into t2 values(i, i, i);
set i=i+1;
end while;

end;;
delimiter ;
call idata();

为了便于后面量化说明,我在表 t1 里,插入了 1000 行数据,每一行的 a=1001-id 的值。也就是说,表 t1 中字段 a 是逆序的。同时,我在表 t2 中插入了 100 万行数据。

Multi-Range Read 优化

在介绍 join 语句的优化方案之前,我需要先和你介绍一个知识点,即:Multi-Range Read 优化 (MRR)。这个优化的主要目的是尽量使用顺序读盘
在[[基础篇 1-8节#04 深入浅出索引(上)]]中,我和你介绍 InnoDB 的索引结构时,提到了“回表”的概念。我们先来回顾一下这个概念。回表是指,InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去查整行数据的过程。
然后,有同学在留言区问到,回表过程是一行行地查数据,还是批量地查数据?
我们先来看看这个问题。假设,我执行这个语句:

1
select * from t1 where a>=1 and a<=100;

主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图 1 所示。
image.png
如果随着 a 的值递增顺序查询的话,id 的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。
因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。
这,就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:

  1. 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
  2. 将 read_rnd_buffer 中的 id 进行递增排序
  3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。
另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置 **set optimizer_switch="mrr_cost_based=off"**。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)
下面两幅图就是使用了 MRR 优化后的执行流程和 explain 结果。
image.png
image.png
从图 3 的 explain 结果中,我们可以看到 Extra 字段多了 Using MRR,表示的是用上了 MRR 优化。而且,由于我们在 read_rnd_buffer 中按照 id 做了排序,所以最后得到的结果集也是按照主键 id 递增顺序的,也就是与图 1 结果集中行的顺序相反。
到这里,我们小结一下。
MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

Batched Key Access

理解了 MRR 性能提升的原理,我们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Access(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化。
我们再来看看上一篇文章中用到的 NLJ 算法的流程图:
image.png
NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了。
那怎么才能一次性地多传些值给表 t2 呢?方法就是,从表 t1 里一次性地多拿些行出来,一起传给表 t2。
既然如此,我们就把表 t1 的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是 join_buffer
通过上一篇文章,我们知道 join_buffer 在 BNL 算法里的作用,是暂存驱动表的数据。但是在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中。
如图 5 所示,是上面的 NLJ 算法优化后的 BKA 算法的流程。
image.png
图中,我在 join_buffer 中放入的数据是 P1P100,表示的是只会取查询需要的字段。当然,如果 join buffer 放不下 P1P100 的所有数据,就会把这 100 行数据分成多段执行上图的流程。
那么,这个 BKA 算法到底要怎么启用呢?
如果要使用 BKA 优化算法的话,你需要在执行 SQL 语句之前,先设置

1
set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';

其中,前两个参数的作用是要启用 MRR。这么做的原因是,BKA 算法的优化要依赖于 MRR

BNL 算法的性能问题

说完了 NLJ 算法的优化,我们再来看 BNL 算法的优化。
我在上一篇文章末尾,给你留下的思考题是,使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致 IO 压力大以外,还会对系统有什么影响呢?
在[[#33 我查这么多数据,会不会把数据库内存打爆?]]中,我们说到 InnoDB 的 LRU 算法的时候提到,由于 InnoDB 对 Bufffer Pool 的 LRU 算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域。如果 1 秒之后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响就不大。
但是,如果一个使用 BNL 算法的 join 语句,多次扫描一个冷表,而且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部
这种情况对应的,是冷表的数据量小于整个 Buffer Pool 的 3/8,能够完全放入 old 区域的情况。
如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入 young 区域
由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页,很可能在 1 秒之内就被淘汰了。这样,就会导致这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。
也就是说,这两种情况都会影响 Buffer Pool 的正常运作。
大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。
为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。
也就是说,BNL 算法对系统的影响主要包括三个方面:

  1. 可能会多次扫描被驱动表,占用磁盘 IO 资源;
  2. 判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
  3. 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。

我们执行语句之前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL 算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法
接下来,我们就具体看看,这个优化怎么做?

BNL 转 BKA

一些情况下,我们可以直接在被驱动表上建索引,这时就可以直接转成 BKA 算法了。
但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:

1
select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;

我们在文章开始的时候,在表 t2 中插入了 100 万行数据,但是经过 where 条件过滤后,需要参与 join 的只有 2000 行数据。如果这条语句同时是一个低频的 SQL 语句,那么再为这个语句在表 t2 的字段 b 上创建一个索引就很浪费了。
但是,如果使用 BNL 算法来 join 的话,这个语句的执行流程是这样的:

  1. 把表 t1 的所有字段取出来,存入 join_buffer 中。这个表只有 1000 行,join_buffer_size 默认值是 256k,可以完全存入。
  2. 扫描表 t2,取出每一行数据跟 join_buffer 中的数据进行对比,
    • 如果不满足 t1.b=t2.b,则跳过;
    • 如果满足 t1.b=t2.b, 再判断其他条件,也就是是否满足 t2.b 处于 [1,2000] 的条件,如果是,就作为结果集的一部分返回,否则跳过。

我在上一篇文章中说过,对于表 t2 的每一行,判断 join 是否满足的时候,都需要遍历 join_buffer 中的所有行。因此判断等值条件的次数是 1000*100 万 =10 亿次,这个判断的工作量很大。
image.png
image.png
可以看到,explain 结果里 Extra 字段显示使用了 BNL 算法。在我的测试环境里,这条语句需要执行 1 分 11 秒。
在表 t2 的字段 b 上创建索引会浪费资源,但是不创建索引的话这个语句的等值条件要判断 10 亿次,想想也是浪费。那么,有没有两全其美的办法呢?
这时候,我们可以考虑使用临时表。使用临时表的大致思路是:

  1. 把表 t2 中满足条件的数据放在临时表 tmp_t 中;
  2. 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引
  3. 让表 t1 和 tmp_t 做 join 操作。

此时,对应的 SQL 语句的写法如下:

1
2
3
create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

图 8 就是这个语句序列的执行效果。
image.png
可以看到,整个过程 3 个语句执行时间的总和还不到 1 秒,相比于前面的 1 分 11 秒,性能得到了大幅提升。接下来,我们一起看一下这个过程的消耗:

  1. 执行 insert 语句构造 temp_t 表并插入数据的过程中,对表 t2 做了全表扫描,这里扫描行数是 100 万。
  2. 之后的 join 语句,扫描表 t1,这里的扫描行数是 1000;join 比较过程中,做了 1000 次带索引的查询。相比于优化前的 join 语句需要做 10 亿次条件判断来说,这个优化效果还是很明显的。

总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能

扩展 -hash join

看到这里你可能发现了,其实上面计算 10 亿次那个操作,看上去有点儿傻。如果 join_buffer 里面维护的不是一个无序数组,而是一个哈希表的话,那么就不是 10 亿次判断,而是 100 万次 hash 查找。这样的话,整条语句的执行速度就快多了吧?
确实如此。
这,也正是 MySQL 的优化器和执行器一直被诟病的一个原因:不支持哈希 join。并且,MySQL 官方的 roadmap,也是迟迟没有把这个优化排上议程。
实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下:

  1. select * from t1;取得表 t1 的全部 1000 行数据,在业务端存入一个 hash 结构,比如 C++ 里的 set、PHP 的数组这样的数据结构。
  2. select * from t2 where b>=1 and b<=2000; 获取表 t2 中满足条件的 2000 行数据。
  3. 把这 2000 行数据,一行一行地取到业务端,到 hash 结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。

理论上,这个过程会比临时表方案的执行速度还要快一些。如果你感兴趣的话,可以自己验证一下。

小结

今天,我和你分享了 Index Nested-Loop Join(NLJ)和 Block Nested-Loop Join(BNL)的优化方法。
在这些优化方法中:

  1. BKA 优化是 MySQL 已经内置支持的,建议你默认使用
  2. BNL 算法效率低,建议你都尽量转成 BKA 算法。优化的方向就是给被驱动表的关联字段加上索引;
  3. 基于临时表的改进方案,对于能够提前过滤出小数据的 join 语句来说,效果还是很好的;
  4. MySQL 目前的版本还不支持 hash join,但你可以配合应用端自己模拟出来,理论上效果要好于临时表的方案。

这里有时也会遇到优化器不完善的地方,有时候不得不用force index~

问题

我们在讲 join 语句的这两篇文章中,都只涉及到了两个表的 join。那么,现在有一个三个表 join 的需求,假设这三个表的表结构如下:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

create table t2 like t1;
create table t3 like t2;
insert into ... // 初始化三张表的数据

语句的需求实现如下的 join 逻辑:

1
select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;

现在为了得到最快的执行速度,如果让你来设计表 t1、t2、t3 上的索引,来支持这个 join 语句,你会加哪些索引呢?
同时,如果我希望你用 straight_join 来重写这个语句,配合你创建的索引,你就需要安排连接顺序,你主要考虑的因素是什么呢?
第一原则是要尽量使用 BKA 算法。需要注意的是,使用 BKA 算法的时候,并不是“先计算两个表 join 的结果,再跟第三个表 join”,而是直接嵌套查询的。
具体实现是:在 t1.c>=X、t2.c>=Y、t3.c>=Z 这三个条件里,选择一个经过过滤以后,数据最少的那个表,作为第一个驱动表。此时,可能会出现如下两种情况。
第一种情况,如果选出来是表 t1 或者 t3,那剩下的部分就固定了。

  1. 如果驱动表是 t1,则连接顺序是 t1->t2->t3,要在被驱动表字段创建上索引,也就是 t2.a 和 t3.b 上创建索引;
  2. 如果驱动表是 t3,则连接顺序是 t3->t2->t1,需要在 t2.b 和 t1.a 上创建索引。

同时,我们还需要在第一个驱动表的字段 c 上创建索引
第二种情况是,如果选出来的第一个驱动表是表 t2 的话,则需要评估另外两个条件的过滤效果
总之,整体的思路就是,尽量让每一次参与 join 的驱动表的数据集,越小越好,因为这样我们的驱动表就会越小。

36 | 为什么临时表可以重名?

在上一篇文章中,我们在优化 join 查询的时候使用到了临时表。当时,我们是这么用的:

1
2
3
4
create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

你可能会有疑问,为什么要用临时表呢?直接用普通表是不是也可以呢?
今天我们就从这个问题说起:临时表有哪些特征,为什么它适合这个场景?
这里,我需要先帮你厘清一个容易误解的问题:有的人可能会认为,临时表就是内存表。但是,这两个概念可是完全不同的

  • 内存表,指的是使用 Memory 引擎的表,建表语法是 create table … engine=memory。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表
  • 而临时表,可以使用各种引擎类型 。如果是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,写数据的时候是写到磁盘上的。当然,临时表也可以使用 Memory 引擎

临时表和内存表,是两个概念。临时表,一般是人手动创建。 内存表,是mysql自动创建和销毁的。

弄清楚了内存表和临时表的区别以后,我们再来看看临时表有哪些特征。

临时表的特性

为了便于理解,我们来看下下面这个操作序列:
image.png
可以看到,临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …。
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表
  5. show tables 命令不显示临时表

由于临时表只能被创建它的 session 访问,所以在这个 session 结束的时候,会自动删除临时表。也正是由于这个特性,临时表就特别适合我们文章开头的 join 优化这种场景。为什么呢?
原因主要包括以下两个方面:

  1. 不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
  2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。

临时表的应用

由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。
一般分库分表的场景,就是要把一个逻辑上的大表分散到不同的数据库实例上。比如。将一个大表 ht,按照字段 f,拆分成 1024 个分表,然后分布到 32 个数据库实例上。如下图所示:
image.png
一般情况下,这种分库分表系统都有一个中间层 proxy。不过,也有一些方案会让客户端直接连接数据库,也就是没有 proxy 这一层。
在这个架构中,分区 key 的选择是以“减少跨库和跨表查询”为依据的。如果大部分的语句都会包含 f 的等值条件,那么就要用 f 做分区键。这样,在 proxy 这一层解析完 SQL 语句以后,就能确定将这条语句路由到哪个分表做查询。
比如下面这条语句:

1
select v from ht where f=N;

这时,我们就可以通过分表规则(比如,N%1024) 来确认需要的数据被放在了哪个分表上。这种语句只需要访问一个分表,是分库分表方案最欢迎的语句形式了。
但是,如果这个表上还有另外一个索引 k,并且查询语句是这样的:

1
select v from ht where k >= M order by t_modified desc limit 100;

这时候,由于查询条件里面没有用到分区字段 f,只能到所有的分区中去查找满足条件的所有行,然后统一做 order by 的操作。这种情况下,有两种比较常用的思路。
第一种思路是, 在 proxy 层的进程代码中实现排序。
这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显:

  1. 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如 group by,甚至 join 这样的操作,对中间层的开发能力要求比较高;
  2. 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题。

另一种思路就是, 把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作。
比如上面这条语句,执行流程可以类似这样:

  • 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified;
  • 在各个分库上执行
1
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
  • 把分库执行的结果插入到 temp_ht 表中;
  • 执行
1
select v from temp_ht order by t_modified desc limit 100; 

得到结果。
这个过程对应的流程图如下所示:
image.png
在实践中,我们往往会发现每个分库的计算量都不饱和,所以会直接把临时表 temp_ht 放到 32 个分库中的某一个上。 这时的查询逻辑与图 3 类似,你可以自己再思考一下具体的流程。

为什么临时表可以重名?

你可能会问,不同线程可以创建同名的临时表,这是怎么做到的呢?
接下来,我们就看一下这个问题。
我们在执行

1
create temporary table temp_t(id int primary key)engine=innodb;

这个语句的时候,MySQL 要给这个 InnoDB 表创建一个 frm 文件保存表结构定义,还要有地方保存表数据。
这个 frm 文件放在临时文件目录下,文件名的后缀是.frm,前缀是“#sql{进程 id}_{线程 id}_ 序列号”。你可以使用 select @@tmpdir 命令,来显示实例的临时文件目录。
而关于表中数据的存放方式,在不同的 MySQL 版本中有着不同的处理方式:

  • 在 5.6 以及之前的版本里,MySQL 会在临时文件目录下创建一个相同前缀、以.ibd 为后缀的文件,用来存放数据文件;
  • 而从 5.7 版本开始,MySQL 引入了一个临时文件表空间,专门用来存放临时文件的数据。因此,我们就不需要再创建 ibd 文件了。

参数innodb_temp_data_file_path:定义临时表空间的路径、文件名、初始化大小和最大上限。

从文件名的前缀规则,我们可以看到,其实创建一个叫作 t1 的 InnoDB 临时表,MySQL 在存储上认为我们创建的表名跟普通表 t1 是不同的,因此同一个库下面已经有普通表 t1 的情况下,还是可以再创建一个临时表 t1 的。
为了便于后面讨论,我先来举一个例子。
image.png
这个进程的进程号是 1234,session A 的线程 id 是 4,session B 的线程 id 是 5。所以你看到了,session A 和 session B 创建的临时表,在磁盘上的文件不会重名
MySQL 维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表,每个表都对应一个 table_def_key。

  • 一个普通表的 table_def_key 的值是由“库名 + 表名”得到的,所以如果你要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了。
  • 而对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”。【所以不同线程之间,临时表可以重名】

也就是说,session A 和 sessionB 创建的两个临时表 t1,它们的 table_def_key 不同,磁盘文件名也不同,因此可以并存。
在实现上,每个线程都维护了自己的临时表链表。这样每次 session 内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;在 session 结束的时候,对链表里的每个临时表,执行 “DROP TEMPORARY TABLE + 表名”操作。【session内先操作临时表,找不到再操作普通表】
这时候你会发现,binlog 中也记录了 DROP TEMPORARY TABLE 这条命令。你一定会觉得奇怪,临时表只在线程内自己可以访问,为什么需要写到 binlog 里面?
这,就需要说到主备复制了。

临时表和主备复制

既然写 binlog,就意味着备库需要。
你可以设想一下,在主库上执行下面这个语句序列:

1
2
3
4
create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/
create temporary table temp_t like t_normal;/*Q2*/
insert into temp_t values(1,1);/*Q3*/
insert into t_normal select * from temp_t;/*Q4*/

如果关于临时表的操作都不记录,那么在备库就只有 create table t_normal 表和 insert into t_normal select * from temp_t 这两个语句的 binlog 日志,备库在执行到 insert into t_normal 的时候,就会报错“表 temp_t 不存在”。
你可能会说,如果把 binlog 设置为 row 格式就好了吧?因为 binlog 是 row 格式时,在记录 insert into t_normal 的 binlog 时,记录的是这个操作的数据,即:write_row event 里面记录的逻辑是“插入一行数据(1,1)”。
确实是这样。如果当前的 binlog_format=row,那么跟临时表有关的语句,就不会记录到 binlog 里。也就是说,只在 binlog_format=statment/mixed 的时候,binlog 中才会记录临时表的操作

在 binlog_format=’row’的时候,临时表的操作不记录到 binlog 中,也省去了不少麻烦,这也可以成为你选择 binlog_format 时的一个考虑因素。

这种情况下,创建临时表的语句会传到备库执行,因此备库的同步线程就会创建这个临时表。主库在线程退出的时候,会自动删除临时表,但是备库同步线程是持续在运行的。所以,这时候我们就需要在主库上再写一个 DROP TEMPORARY TABLE 传给备库执行。
之前有人问过我一个有趣的问题: MySQL 在记录 binlog 的时候,不论是 create table 还是 alter table 语句,都是原样记录,甚至于连空格都不变。但是如果执行 drop table t_normal,系统记录 binlog 就会写成:

1
DROP TABLE `t_normal` /* generated by server */

也就是改成了标准的格式。为什么要这么做呢 ?
现在你知道原因了,那就是:drop table 命令是可以一次删除多个表的。比如,在上面的例子中,设置 binlog_format=row,如果主库上执行 “drop table t_normal, temp_t”这个命令,那么 binlog 中就只能记录:

1
DROP TABLE `t_normal` /* generated by server */

因为备库上并没有表 temp_t,将这个命令重写后再传到备库执行,才不会导致备库同步线程停止。
所以,drop table 命令记录 binlog 的时候,就必须对语句做改写。“/* generated by server */”说明了这是一个被服务端改写过的命令。

说到主备复制,还有另外一个问题需要解决:主库上不同的线程创建同名的临时表是没关系的,但是传到备库执行是怎么处理的呢?
现在,我给你举个例子,下面的序列中实例 S 是 M 的备库。
image.png
主库 M 上的两个 session 创建了同名的临时表 t1,这两个 create temporary table t1 语句都会被传到备库 S 上。
但是,备库的应用日志线程是共用的,也就是说要在应用线程里面先后执行这个 create 语句两次。(即使开了多线程复制,也可能被分配到从库的同一个 worker 中执行)。那么,这会不会导致同步线程报错 ?
显然是不会的,否则临时表就是一个 bug 了。也就是说,备库线程在执行的时候,要把这两个 t1 表当做两个不同的临时表来处理。这,又是怎么实现的呢?
MySQL 在记录 binlog 的时候,会把主库执行这个语句的线程 id 写到 binlog 中。这样,在备库的应用线程就能够知道执行每个语句的主库线程 id,并利用这个线程 id 来构造临时表的 table_def_key:

  1. session A 的临时表 t1,在备库的 table_def_key 就是:库名 +t1+“M 的 serverid”+“session A 的 thread_id”;
  2. session B 的临时表 t1,在备库的 table_def_key 就是 :库名 +t1+“M 的 serverid”+“session B 的 thread_id”。

由于 table_def_key 不同,所以这两个表在备库的应用线程里面是不会冲突的。

以上可以总结为:
1、临时表 redolog:不记录,因为崩溃之后,临时表全没了,也不需要恢复;
2、undolog:需要记录,5.6之前是和普通表放一块的;5.7之后放在临时表空间的;
3、binlog:row格式不用记,statement/mix需要记录

上面说到的这种临时表,是用户自己创建的 ,也可以称为用户临时表。与它相对应的,就是内部临时表,在[[实践篇-02 16-22节#17 如何正确地显示随机消息?]]中介绍过

问题

下面的语句序列是创建一个临时表,并将其改名:
image.png
可以看到,我们可以使用 alter table 语法修改临时表的表名,而不能使用 rename 语法。你知道这是什么原因吗?
在实现上,执行 rename table 语句的时候,要求按照 “库名 / 表名.frm”的规则去磁盘找文件,但是临时表在磁盘上的 frm 文件是放在 tmpdir 目录下的,并且文件名的规则是“#sql{进程 id}_{线程 id}_ 序列号.frm”,因此会报“找不到文件名”的错误。

其他问题

[!question]
老师,不同线程可以使用同名的临时表,这个没有问题。但是如果在程序中,用的是连接池中的连接来操作的,而这些连接不会释放,和数据库保持长连接。这样使用临时表会有问题吗?。

作者回复: 会,“临时表会自动回收”这个功能,主要用于“应用程序异常断开、MySQL异常重启”后,不需要主动去删除表。
而平时正常使用的时候,用完删除,还是应该有的好习惯。😆
好问题,新年快乐~

[!question]
有个问题,insert into select语句好像会给select的表加锁,如果没有索引,就锁全表,是不是这样?什么时候可以大胆的用这类语句?

“insert into select语句好像会给select的表加锁,如果没有索引,就锁全表”,是的。
这类最好不要很大胆😆,如果不是业务急需的,从源表导出来再写到目标表也是好的。
后面第40篇会说到哈。

[!abstract]
一个简单易懂的总结

临时表建表语法create temporary table ,和普通的表不一样,和内存表也不一样。
内存表数据保存到内存里,重启会丢失,临时表会写入到磁盘。
临时表只对自己的session中可见,session结束后自动删除表结构和表数据。
适用场景是分库分表,查询到的数据在临时表中做聚合。
临时表可以重名,实际的存储文件名有线程id,在内存中表的命名有table_ref_key,是由库名加表名加serverid+线程id组成。
bin log设置为row模式,临时表不会同步到备库中,设置为statement模式,会同步到备库中。

[!question]
老师有个问题,如果说创建临时表在主库,查询的时候打到从库上,查询是不是就有问题了,查询主库的线程id跟从库不一致

作者回复: 是的,所以一般一个事务创建临时表以后,读写分离就会默认接下来的请求都路由到主库去了

37 | 什么时候会使用内部临时表?

sort buffer、内存临时表和 join buffer。这三个数据结构都是用来存放语句执行过程中的中间数据,以辅助 SQL 语句的执行的。其中,我们在排序的时候用到了 sort buffer,在使用 join 语句的时候用到了 join buffer。

order by 会使用 sort buffer 在内存中进行排序;
join buffer 应对的是被驱动表没有用上索引的情况,用于加速驱动表的全表扫描与比对。

然后,你可能会有这样的疑问,MySQL 什么时候会使用内部临时表呢?
今天这篇文章,我就先给你举两个需要用到内部临时表的例子,来看看内部临时表是怎么工作的。然后,我们再来分析,什么情况下会使用内部临时表。

union 执行流程

为了便于量化分析,我用下面的表 t1 来举例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
create table t1(id int primary key, a int, b int, index(a));
delimiter ;;
create procedure idata()
begin
declare i int;

set i=1;
while(i<=1000)do
insert into t1 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();

然后,我们执行下面这条语句:

1
(select 1000 as f) union (select id from t1 order by id desc limit 2);

这条语句用到了 union,它的语义是,取这两个子查询结果的并集并集的意思就是这两个集合加起来,重复的行只保留一行
下图是这个语句的 explain 结果。
image.png
可以看到:

  • 第二行的 key=PRIMARY,说明第二个子句用到了索引 id。
  • 第三行的 Extra 字段,表示在对子查询的结果集做 union 的时候,使用了临时表 (Using temporary)。

这个语句的执行流程是这样的:

  1. 创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段。【因为union是并集】
  2. 执行第一个子查询,得到 1000 这个值,并存入临时表中。
  3. 执行第二个子查询:
    • 拿到第一行 id=1000,试图插入临时表中。但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行;
    • 取到第二行 id=999,插入临时表成功。
  4. 从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是 1000 和 999。

这个过程的流程图如下所示:
image.png
可以看到,这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键 id 的唯一性约束,实现了 union 的语义。
顺便提一下,如果把上面这个语句中的 union 改成 union all 的话,就没有了“去重”的语义。这样执行的时候,就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端。因此也就不需要临时表了。
image.png
可以看到,第二行的 Extra 字段显示的是 Using index,表示只使用了覆盖索引,没有用临时表了。

group by 执行流程

另外一个常见的使用临时表的例子是 group by,我们来看一下这个语句:

1
select id%10 as m, count(*) as c from t1 group by m;

这个语句的逻辑是把表 t1 里的数据,按照 id%10 进行分组统计,并按照 m 的结果排序后输出。它的 explain 结果如下:
image.png
在 Extra 字段里面,我们可以看到三个信息:

  • Using index,表示这个语句使用了覆盖索引,选择了索引 a,不需要回表;
  • Using temporary,表示使用了临时表
  • Using filesort,表示需要排序

这个语句的执行流程是这样的:

  1. 创建内存临时表,表里有两个字段 m 和 c,主键是 m;
  2. 扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
    • 如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
    • 如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
  3. 遍历完成后,再根据字段 m 做排序,得到结果集返回给客户端。

MySQL 8.0.13版本及以后不会对group by做默认排序了。需要排序要手动加上order by。

这个流程的执行图如下:
image.png
图中最后一步,对内存临时表的排序,在[[实践篇-02 16-22节#17 如何正确地显示随机消息?]]中已经有过介绍,我把图贴过来,方便你回顾。
image.png
其中,临时表的排序过程就是图 6 中虚线框内的过程。
接下来,我们再看一下这条语句的执行结果:
image.png
如果你的需求并不需要对结果进行排序,那你可以在 SQL 语句末尾增加 order by null,也就是改成:

1
select id%10 as m, count(*) as c from t1 group by m order by null;

这样就跳过了最后排序的阶段,直接从临时表中取数据返回。返回的结果如图 8 所示。【MySQL5版本,group by默认返回的是排序后的结果】
image.png
由于表 t1 中的 id 值是从 1 开始的,因此返回的结果集中第一行是 id=1;扫描到 id=10 的时候才插入 m=0 这一行,因此结果集里最后一行才是 m=0。
这个例子里由于临时表只有 10 行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,参数 tmp_table_size 就是控制这个内存大小的,默认是 16M。
如果我执行下面这个语句序列:

1
set tmp_table_size=1024;select id%100 as m, count(*) as c from t1 group by m order by null limit 10;

把内存临时表的大小限制为最大 1024 字节,并把语句改成 id % 100,这样返回结果里有 100 行数据。但是,这时的内存临时表大小不够存下这 100 行数据,也就是说,执行过程中会发现内存临时表大小到达了上限(1024 字节)。

这里内存不够的原因是:m占4个字节,c占8个字节,100行数据,需要1200字节,超过了1024。 count(*)的返回值是BIGINT,占8个字节,之前以为是4个字节。

那么,这时候就会把内存临时表转成磁盘临时表磁盘临时表默认使用的引擎是 InnoDB。 这时,返回的结果如图 9 所示。
image.png
如果这个表 t1 的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。

查了下:将内存表转换为磁盘临时表,并将其中放不下的部分数据写入到磁盘文件中。而内存表中能够放下的数据则会继续存储在内存中,直到查询结束或者内存不足时才会被释放。这样可以尽可能地减少磁盘 I/O 操作,提高查询效率。

group by 优化方法 – 索引

可以看到,不论是使用内存临时表还是磁盘临时表,group by 逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的。如果表的数据量比较大,上面这个 group by 语句执行起来就会很慢,我们有什么优化的方法呢?
要解决 group by 语句的优化问题,你可以先想一下这个问题:执行 group by 语句为什么需要临时表?
group by 的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的 id%100 的结果是无序的,所以我们就需要有一个临时表,来记录并统计结果。
那么,如果扫描过程中可以保证出现的数据是有序的,是不是就简单了呢?
假设,现在有一个类似图 10 的这么一个数据结构,我们来看看 group by 可以怎么做。
image.png
可以看到,如果可以确保输入的数据是有序的,那么计算 group by 的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程:

  • 当碰到第一个 1 的时候,已经知道累积了 X 个 0,结果集里的第一行就是 (0,X);
  • 当碰到第一个 2 的时候,已经知道累积了 Y 个 1,结果集里的第二行就是 (1,Y);

按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。
你一定想到了,InnoDB 的索引,就可以满足这个输入有序的条件。
在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新。你可以用下面的方法创建一个列 z,然后在 z 列上创建一个索引(如果是 MySQL 5.6 及之前的版本,你也可以创建普通列和索引,来解决这个问题)。【空间换时间】

1
alter table t1 add column z int generated always as(id % 100), add index(z);

这样,索引 z 上的数据就是类似图 10 这样有序的了。上面的 group by 语句就可以改成:

1
select z, count(*) as c from t1 group by z;

优化后的 group by 语句的 explain 结果,如下图所示:
image.png
从 Extra 字段可以看到,这个语句的执行不再需要临时表,也不需要排序了。

group by 优化方法 – 直接排序

所以,如果可以通过加索引来完成 group by 逻辑就再好不过了。但是,如果碰上不适合创建索引的场景,我们还是要老老实实做排序的。那么,这时候的 group by 要怎么优化呢?
如果我们明明知道,一个 group by 语句中需要放到临时表上的数据量特别大,却还是要按照“先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表”,看上去就有点儿傻。
那么,我们就会想了,MySQL 有没有让我们直接走磁盘临时表的方法呢?
答案是,有的。
在 group by 语句中加入 SQL_BIG_RESULT 这个提示(hint),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。
MySQL 的优化器一看,磁盘临时表是 B+ 树存储,存储效率不如数组来得高。所以,既然你告诉我数据量很大,那从磁盘空间考虑,还是直接用数组来存吧。
因此,下面这个语句

1
select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m;

的执行流程就是这样的:

  1. 初始化 sort_buffer,确定放入一个整型字段,记为 m;
  2. 扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
  3. 扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
  4. 排序完成后,就得到了一个有序数组。

根据有序数组,得到数组里面的不同值,以及每个值的出现次数。这一步的逻辑,你已经从前面的图 10 中了解过了。
下面两张图分别是执行流程图和执行 explain 命令得到的结果。
image.png
image.png
从 Extra 字段可以看到,这个语句的执行没有再使用临时表,而是直接用了排序算法。
基于上面的 union、union all 和 group by 语句的执行过程的分析,我们来回答文章开头的问题:MySQL 什么时候会使用内部临时表?

  1. 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
  2. join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构
  3. 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数

[!abstract]
分清楚三者的区别:
joinBuffer: 用于在join语句中对驱动表进行暂存用,是无序数组;所以为什么BKA算法在用了JoinBuffer后不能原地排序只好借由MRR算法排序;
sortBuffer: order by 语句时,会将排序列进行暂存。sortBuffer不够的情况下,则是分段排序最后整合;
临时表:一种二维表结构,有主键,有field。所以如果中间数据需要用到二维表特性,那么就需要使用临时表,且内存临时表不够的前提下,会转为使用磁盘临时表

小结

通过今天这篇文章,我重点和你讲了 group by 的几种实现算法,从中可以总结一些使用的指导原则:

  1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null
  2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort
  3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
  4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

问题

文章中图 8 和图 9 都是 order by null,为什么图 8 的返回结果里面,0 是在结果集的最后一行,而图 9 的结果里面,0 是在结果集的第一行?
备注:下一篇进行解答
【图九使用的是磁盘临时表,磁盘临时表使用的引擎是innodb,innodb是索引组织表,按主键顺序存储数据,所以是按照m字段有序的。】

其他问题

[!question]
如果只需要去重,不需要执行聚合函数,distinct 和group by那种效率高一些呢?

好问题,我加到后面文章中。 简单说下结论,只需要去重的话,如果没有limit,是一样的; 有limit的话,distinct 快些。

[!question]
我用infobright列式存储引擎explain出来一个group by语句, 发现也会用到内存临时表和文件排序、然后使用SQL_BIG_RESULT也有同样优化效果; 这是不是说明sort_buffer、join_buffer、内存临时表和磁盘临时表与数据库引擎类型其实是独立开的呢?

作者回复: 是的,非常好的验证和思考👍
sort_buffer、join_buffer、内存临时表和磁盘临时表 都是server层的,引擎间共用

38 | 都说InnoDB好,那还要不要使用Memory引擎?

内存表的数据组织结构

为了便于分析,我来把这个问题简化一下,假设有以下的两张表 t1 和 t2,其中表 t1 使用 Memory 引擎, 表 t2 使用 InnoDB 引擎。

1
2
3
4
create table t1(id int primary key, c int) engine=Memory;
create table t2(id int primary key, c int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);
insert into t2 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);

[!hint]
注意这里的sql最后插入的是(0,0),后面讲到“堆组织表的数据存放方式”的时候会用到

然后,我分别执行 select * from t1 和 select * from t2。
image.png
可以看到,内存表 t1 的返回结果里面 0 在最后一行,而 InnoDB 表 t2 的返回结果里 0 在第一行。
出现这个区别的原因,要从这两个引擎的主键索引的组织方式说起。
表 t2 用的是 InnoDB 引擎,它的主键索引 id 的组织方式,你已经很熟悉了:InnoDB 表的数据就放在主键索引树上,主键索引是 B+ 树。所以表 t2 的数据组织方式如下图所示:
image.png
主键索引上的值是有序存储的。在执行 select * 的时候,就会按照叶子节点从左到右扫描,所以得到的结果里,0 就出现在第一行。
与 InnoDB 引擎不同,Memory 引擎的数据和索引是分开的。我们来看一下表 t1 中的数据内容。
image.png
可以看到,内存表的数据部分以数组的方式单独存放,而主键 id 索引里,存的是每个数据的位置。主键 id 是 hash 索引,可以看到索引上的 key 并不是有序的。【主键和数据都在内存,可寻址】
在内存表 t1 中,当我执行 select * 的时候,走的是全表扫描,也就是顺序扫描这个数组。因此,0 就是最后一个被读到,并放入结果集的数据。
可见,InnoDB 和 Memory 引擎的数据组织方式是不同的:

  • InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
  • 而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)。

从中我们可以看出,这两个引擎的一些典型不同:

  1. InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的;
  2. 当数据文件有空洞的时候,InnoDB 表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值
  3. 数据位置发生变化的时候,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引
  4. InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的
  5. InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。【数组避免不了定长】

由于内存表的这些特性,每个数据行被删除以后,空出的这个位置都可以被接下来要插入的数据复用。比如,如果要在表 t1 中执行:

1
2
3
delete from t1 where id=5;
insert into t1 values(10,10);
select * from t1;

就会看到返回结果里,id=10 这一行出现在 id=4 之后,也就是原来 id=5 这行数据的位置。
需要指出的是,表 t1 的这个主键索引是哈希索引,因此如果执行范围查询,比如

1
select * from t1 where id<5;

是用不上主键索引的,需要走全表扫描。你可以借此再回顾下[[基础篇 1-8节#04 深入浅出索引(上)]]的内容。那如果要让内存表支持范围扫描,应该怎么办呢 ?

hash 索引和 B-Tree 索引

实际上,内存表也是支 B-Tree 索引的。在 id 列上创建一个 B-Tree 索引,SQL 语句可以这么写:

1
alter table t1 add index a_btree_index using btree (id);

这时,表 t1 的数据组织形式就变成了这样:
image.png
新增的这个 B-Tree 索引你看着就眼熟了,这跟 InnoDB 的 b+ 树索引组织形式类似。
作为对比,你可以看一下这下面这两个语句的输出:
image.png
可以看到,执行 select * from t1 where id<5 的时候,优化器会选择 B-Tree 索引**,所以返回结果是 0 到 4。 使用 force index 强行使用主键 id 这个索引,id=0 这一行就在结果集的最末尾了。【对内存表t1强制执行主键索引会使用hash索引】
其实,一般在我们的印象中,内存表的优势是速度快,其中的一个原因就是 Memory 引擎支持 hash 索引。当然,
更重要的原因是,内存表的所有数据都保存在内存,而内存的读写速度总是比磁盘快。
但是,接下来我要跟你说明,为什么我
不建议**你在生产环境上使用内存表。这里的原因主要包括两个方面:

  1. 锁粒度问题;
  2. 数据持久化问题。

内存表的锁

我们先来说说内存表的锁粒度问题。
内存表不支持行锁,只支持表锁。因此,一张表只要有更新,就会堵住其他所有在这个表上的读写操作。
需要注意的是,这里的表锁跟之前我们介绍过的 MDL 锁不同,但都是表级的锁。接下来,我通过下面这个场景,跟你模拟一下内存表的表级锁。
image.png
在这个执行序列里,session A 的 update 语句要执行 50 秒,在这个语句执行期间 session B 的查询会进入锁等待状态。session C 的 show processlist 结果输出如下:
image.png
跟行锁比起来,表锁对并发访问的支持不够好。所以,内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好。

数据持久性问题

接下来,我们再看看数据持久性的问题。
数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。

在M-S架构下:备库重启,内存表清空,此时客户端发送更新请求,主库同步到备库时,主备同步会停止。 在M-M架构下:主库重启后,会在binlog里面写一行delete from t1(为了保证一致性)。

你可能会说,如果数据库异常重启,内存表被清空也就清空了,不会有什么问题啊。但是,在高可用架构下,内存表的这个特点简直可以当做 bug 来看待了。为什么这么说呢?
我们先看看 M-S 架构下,使用内存表存在的问题。
image.png
我们来看一下下面这个时序:

  1. 业务正常访问主库;
  2. 备库硬件升级,备库重启,内存表 t1 内容被清空;
  3. 备库重启后,客户端发送一条 update 语句,修改表 t1 的数据行,这时备库应用线程就会报错“找不到要更新的行”

这样就会导致主备同步停止。当然,如果这时候发生主备切换的话,客户端会看到,表 t1 的数据“丢失”了。
在图 8 中这种有 proxy 的架构里,大家默认主备切换的逻辑是由数据库系统自己维护的。这样对客户端来说,就是“网络断开,重连之后,发现内存表数据丢失了”。
你可能说这还好啊,毕竟主备发生切换,连接会断开,业务端能够感知到异常。
但是,接下来内存表的这个特性就会让使用现象显得更“诡异”了。由于 MySQL 知道重启之后,内存表的数据会丢失。所以,担心主库重启之后,出现主备不一致,MySQL 在实现上做了这样一件事儿:在数据库重启之后,往 binlog 里面写入一行 DELETE FROM t1。【为了保证一致性】
如果你使用是如图 9 所示的双 M 结构的话:
image.png
在备库重启的时候,备库 binlog 里的 delete 语句就会传到主库,然后把主库内存表的内容删除。这样你在使用的时候就会发现,主库的内存表数据突然被清空了。
基于上面的分析,你可以看到,内存表并不适合在生产环境上作为普通数据表使用。
有同学会说,但是内存表执行速度快呀。这个问题,其实你可以这么分析:

  1. 如果你的表更新量大,那么并发度是一个很重要的参考指标,InnoDB 支持行锁,并发度比内存表好
  2. 能放到内存表的数据量都不大。如果你考虑的是读的性能,一个读 QPS 很高并且数据量不大的表,即使是使用 InnoDB,数据也是都会缓存在 InnoDB Buffer Pool 里的。因此,使用 InnoDB 表的读性能也不会差。

如果你是 DBA,可以在建表的审核系统中增加这类规则,要求业务改用 InnoDB 表。我们在文中也分析了,其实 InnoDB 表性能还不错,而且数据安全也有保障。而内存表由于不支持行锁,更新语句会阻塞查询,性能也未必就如想象中那么好。
基于内存表的特性,我们还分析了它的一个适用场景,就是内存临时表。内存表支持 hash 索引,这个特性利用起来,对复杂查询的加速效果还是很不错的。

所以,我建议你把普通内存表都用 InnoDB 表来代替。 但是,有一个场景却是例外的。
这个场景就是,我们在第 35 和 36 篇说到的用户临时表。在数据量可控,不会耗费过多内存的情况下,你可以考虑使用内存表。
内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因:

  1. 临时表不会被其他线程访问,没有并发性的问题;
  2. 临时表重启后也是需要删除的,清空数据这个问题不存在;
  3. 备库的临时表也不会影响主库的用户线程

现在,我们回过头再看一下第 35 篇 join 语句优化的例子,当时我建议的是创建一个 InnoDB 临时表,使用的语句序列是:

1
2
3
create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

了解了内存表的特性,你就知道了, 其实这里使用内存临时表的效果更好,原因有三个:

  1. 相比于 InnoDB 表,使用内存表不需要写磁盘,往表 temp_t 的写数据的速度更快;
  2. 索引 b 使用 hash 索引,查找的速度比 B-Tree 索引快;【指select * from t1 join temp_t on (t1.b=temp_t.b);】
  3. 临时表数据只有 2000 行,占用的内存有限。

因此,你可以对[[#35 join语句怎么优化?]]的语句序列做一个改写,将临时表 t1 改成内存临时表,并且在字段 b 上创建一个 hash 索引。

1
2
3
create temporary table temp_t(id int primary key, a int, b int, index (b))engine=memory;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

image.png
可以看到,不论是导入数据的时间,还是执行 join 的时间,使用内存临时表的速度都比使用 InnoDB 临时表要更快一些

内存表的应用场景:复杂查询的加速【能用上哈希索引】

问题

假设你刚刚接手的一个数据库上,真的发现了一个内存表。备库重启之后肯定是会导致备库的内存表数据被清空,进而导致主备同步停止。这时,最好的做法是将它修改成 InnoDB 引擎表。
假设当时的业务场景暂时不允许你修改引擎,你可以加上什么自动化逻辑,来避免主备同步停止呢?

我们假设的是主库暂时不能修改引擎,那么就把备库的内存表引擎先都改成 InnoDB对于每个内存表,执行

1
2
set sql_log_bin=off;
alter table tbl_name engine=innodb;

这样就能避免备库重启的时候,数据丢失的问题。
由于主库重启后,会往 binlog 里面写“delete from tbl_name”,这个命令传到备库,备库的同名的表数据也会被清空。
因此,就不会出现主备同步停止的问题。
如果由于主库异常重启,触发了 HA,这时候我们之前修改过引擎的备库变成了主库。而原来的主库变成了新备库,在新备库上把所有的内存表(这时候表里没数据)都改成 InnoDB 表。
所以,如果我们不能直接修改主库上的表引擎,可以配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。
同时,跟业务开发同学约定好建表规则,避免创建新的内存表。